image.png

Моделирование оттока клиентов оператора моб.связи
¶

выполнил: Морозов Е.А.


Постановка задачи.¶

Оператор моб.связи ********* хочет прогнозировать отток клиентов с помощью машинного обучения. Для чего предполагается использовать предоставленные оператором обезличенные данные об активности некоторых клиентов, информации об их тарифах и договорах.

*Описание данных*:

Данные представлены в табличном виде, информация содержится в нескольких .csv-файлах:

  • contract.csv — информация о договоре;
  • personal.csv — персональные данные клиента;
  • internet.csv — информация об интернет-услугах;
  • phone.csv — информация об услугах телефонии.

Во всех файлах столбец customerID содержит код клиента.

Информация считается актуальной на 1 февраля 2020.
Данные находятся в файле по адресу ./datasets/.../...


Ход исследования.¶

Исследование и решение задачи разбивается на следующие этапы:

  1. Декомпозиция.
  • Определение целей иследования и типа задачи (регрессия, классификация).

  • Предобработка и исследовательский анализ данных (EDA), поиска взаимосвязей в данных и оценка значимости признаков.

  • Подготовка выборки, исправление ошибок, обогащение данных с пом. синтеза признаков.

  • Разметка данных при необходимости выделения целевого признака.

  1. Выбор моделей и метрик.
  • Выделение признаков и целевого признака, формирование обучающей и тестовой выборок.

  • Обработка значений для машинного обучения (масштабирование и кодирование).

  • Выбор моделей для машинного обучения.

  • Выбор меры для сравнения эффективности обучения моделей. Выбор метрики для оценки модели пользователем.

  1. Обучение моделей.
  • Обучение различных ML-моделей. Настройка гиперпараметров. Проверка на наличие утечек данных.

  • Сравнение полученных результатов для выбора наилучшей модели. Получение оценок на кросс-валидации.

  • Выбор наилучшей модели, получение оценки на тестовой выборке. Обоснование выбора с использованием рабочей и бизнес-метрик.


Исследование.¶

Стоит задача построения бинарной классификации для предсказания оттока клиентов. Модель должна будет определить, остаётся ли клиент активным пользователем услуг - такие клиенты будут отнесены к *классу 1, или прекращает использование - такие клиенты будут отнесены к классу 0*.

Данные выгружены в сыром виде, без разметки целевого признака, её нужно будет провести самостоятельно на основании имеющихся данных. Таблицы набора данных должны быть объединены в один датасет для обогащения пространства признаков.

Предобработка.¶

Импортируем требующиеся библиотеки и прочитаем .csv-файлы в таблицы Pandas.

# импорт библиотек
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
...
from catboost import CatBoostClassifier
...
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
...
from tensorflow import keras
...
warnings.simplefilter('ignore')
pd.options.mode.chained_assignment = None # чтобы избавиться от конфликта pandas и sklearn
In [2]:
# читаем файлы в словарь, где они будут храниться
csv = ['contract.csv','personal.csv','internet.csv','phone.csv']
df_dict = {i[:-4]:pd.read_csv('/datasets/final_provider/'+i, true_values=['Yes','1'],\
                         false_values=['No','0'], parse_dates=[1]) for i in csv}

# вывод информации о хранящихся значениях
print(f'\nСодержимое csv-файлов проекта ({len(csv)} таблицы).')
for file in df_dict:
    print()
    print(f'\t\033[1mТаблица "{file.upper()}"\033[0;0m:')
    print(f'\t- размеры (строк, столбцов) -', df_dict.get(file).shape)
    print(f'\t- фрагмент (см. ниже)')
    display(df_dict.get(file).head(3))
    print(f'\t - пропущенных значений (по столбцам)')
    print(df_dict.get(file).isna().mean().astype('int'))
    print(f'\n\t- типы данных (по столбцам)')
    print(df_dict.get(file).dtypes)

print(f'\n\nПодробная иформация по уникальным значениям столбцов загруженых талиц.')
for file in df_dict:
    print()
    print(f'\t\033[1mДля стобцов таблицы "{file.upper()}"\033[0;0m (размеры {df_dict.get(file).shape}):\n')
    for col in df_dict.get(file).columns:
        print(f'- {col} --- всего уникальных значений {len(df_dict.get(file)[col].unique())}')
        print(f'  список уникальных значений: {df_dict.get(file)[col].sort_values().unique()}\n')
Содержимое csv-файлов проекта (4 таблицы).

	Таблица "CONTRACT":
	- размеры (строк, столбцов) - (7043, 8)
	- фрагмент (см. ниже)
customerID BeginDate EndDate Type PaperlessBilling PaymentMethod MonthlyCharges TotalCharges
0 7590-VHVEG 2020-01-01 No Month-to-month True Electronic check 29.85 29.85
1 5575-GNVDE 2017-04-01 No One year False Mailed check 56.95 1889.5
2 3668-QPYBK 2019-10-01 2019-12-01 00:00:00 Month-to-month True Mailed check 53.85 108.15
	 - пропущенных значений (по столбцам)
customerID          0
BeginDate           0
EndDate             0
Type                0
PaperlessBilling    0
PaymentMethod       0
MonthlyCharges      0
TotalCharges        0
dtype: int64

	- типы данных (по столбцам)
customerID                  object
BeginDate           datetime64[ns]
EndDate                     object
Type                        object
PaperlessBilling              bool
PaymentMethod               object
MonthlyCharges             float64
TotalCharges                object
dtype: object

	Таблица "PERSONAL":
	- размеры (строк, столбцов) - (7043, 5)
	- фрагмент (см. ниже)
customerID gender SeniorCitizen Partner Dependents
0 7590-VHVEG Female 0 True False
1 5575-GNVDE Male 0 False False
2 3668-QPYBK Male 0 False False
	 - пропущенных значений (по столбцам)
customerID       0
gender           0
SeniorCitizen    0
Partner          0
Dependents       0
dtype: int64

	- типы данных (по столбцам)
customerID       object
gender           object
SeniorCitizen     int64
Partner            bool
Dependents         bool
dtype: object

	Таблица "INTERNET":
	- размеры (строк, столбцов) - (5517, 8)
	- фрагмент (см. ниже)
customerID InternetService OnlineSecurity OnlineBackup DeviceProtection TechSupport StreamingTV StreamingMovies
0 7590-VHVEG DSL False True False False False False
1 5575-GNVDE DSL True False True False False False
2 3668-QPYBK DSL True True False False False False
	 - пропущенных значений (по столбцам)
customerID          0
InternetService     0
OnlineSecurity      0
OnlineBackup        0
DeviceProtection    0
TechSupport         0
StreamingTV         0
StreamingMovies     0
dtype: int64

	- типы данных (по столбцам)
customerID          object
InternetService     object
OnlineSecurity        bool
OnlineBackup          bool
DeviceProtection      bool
TechSupport           bool
StreamingTV           bool
StreamingMovies       bool
dtype: object

	Таблица "PHONE":
	- размеры (строк, столбцов) - (6361, 2)
	- фрагмент (см. ниже)
customerID MultipleLines
0 5575-GNVDE No
1 3668-QPYBK No
2 9237-HQITU No
	 - пропущенных значений (по столбцам)
customerID       0
MultipleLines    0
dtype: int64

	- типы данных (по столбцам)
customerID       object
MultipleLines    object
dtype: object


Подробная иформация по уникальным значениям столбцов загруженых талиц.

	Для стобцов таблицы "CONTRACT" (размеры (7043, 8)):

- customerID --- всего уникальных значений 7043
  список уникальных значений: ['0002-ORFBO' '0003-MKNFE' '0004-TLHLJ' ... '9992-UJOEL' '9993-LHIEB'
 '9995-HOTOH']

- BeginDate --- всего уникальных значений 77
  список уникальных значений: ['2013-10-01T00:00:00.000000000' '2013-11-01T00:00:00.000000000'
 '2013-12-01T00:00:00.000000000' '2014-01-01T00:00:00.000000000'
 '2014-02-01T00:00:00.000000000' '2014-03-01T00:00:00.000000000'
 '2014-04-01T00:00:00.000000000' '2014-05-01T00:00:00.000000000'
 '2014-06-01T00:00:00.000000000' '2014-07-01T00:00:00.000000000'
 '2014-08-01T00:00:00.000000000' '2014-09-01T00:00:00.000000000'
 '2014-10-01T00:00:00.000000000' '2014-11-01T00:00:00.000000000'
 '2014-12-01T00:00:00.000000000' '2015-01-01T00:00:00.000000000'
 '2015-02-01T00:00:00.000000000' '2015-03-01T00:00:00.000000000'
 '2015-04-01T00:00:00.000000000' '2015-05-01T00:00:00.000000000'
 '2015-06-01T00:00:00.000000000' '2015-07-01T00:00:00.000000000'
 '2015-08-01T00:00:00.000000000' '2015-09-01T00:00:00.000000000'
 '2015-10-01T00:00:00.000000000' '2015-11-01T00:00:00.000000000'
 '2015-12-01T00:00:00.000000000' '2016-01-01T00:00:00.000000000'
 '2016-02-01T00:00:00.000000000' '2016-03-01T00:00:00.000000000'
 '2016-04-01T00:00:00.000000000' '2016-05-01T00:00:00.000000000'
 '2016-06-01T00:00:00.000000000' '2016-07-01T00:00:00.000000000'
 '2016-08-01T00:00:00.000000000' '2016-09-01T00:00:00.000000000'
 '2016-10-01T00:00:00.000000000' '2016-11-01T00:00:00.000000000'
 '2016-12-01T00:00:00.000000000' '2017-01-01T00:00:00.000000000'
 '2017-02-01T00:00:00.000000000' '2017-03-01T00:00:00.000000000'
 '2017-04-01T00:00:00.000000000' '2017-05-01T00:00:00.000000000'
 '2017-06-01T00:00:00.000000000' '2017-07-01T00:00:00.000000000'
 '2017-08-01T00:00:00.000000000' '2017-09-01T00:00:00.000000000'
 '2017-10-01T00:00:00.000000000' '2017-11-01T00:00:00.000000000'
 '2017-12-01T00:00:00.000000000' '2018-01-01T00:00:00.000000000'
 '2018-02-01T00:00:00.000000000' '2018-03-01T00:00:00.000000000'
 '2018-04-01T00:00:00.000000000' '2018-05-01T00:00:00.000000000'
 '2018-06-01T00:00:00.000000000' '2018-07-01T00:00:00.000000000'
 '2018-08-01T00:00:00.000000000' '2018-09-01T00:00:00.000000000'
 '2018-10-01T00:00:00.000000000' '2018-11-01T00:00:00.000000000'
 '2018-12-01T00:00:00.000000000' '2019-01-01T00:00:00.000000000'
 '2019-02-01T00:00:00.000000000' '2019-03-01T00:00:00.000000000'
 '2019-04-01T00:00:00.000000000' '2019-05-01T00:00:00.000000000'
 '2019-06-01T00:00:00.000000000' '2019-07-01T00:00:00.000000000'
 '2019-08-01T00:00:00.000000000' '2019-09-01T00:00:00.000000000'
 '2019-10-01T00:00:00.000000000' '2019-11-01T00:00:00.000000000'
 '2019-12-01T00:00:00.000000000' '2020-01-01T00:00:00.000000000'
 '2020-02-01T00:00:00.000000000']

- EndDate --- всего уникальных значений 5
  список уникальных значений: ['2019-10-01 00:00:00' '2019-11-01 00:00:00' '2019-12-01 00:00:00'
 '2020-01-01 00:00:00' 'No']

- Type --- всего уникальных значений 3
  список уникальных значений: ['Month-to-month' 'One year' 'Two year']

- PaperlessBilling --- всего уникальных значений 2
  список уникальных значений: [False  True]

- PaymentMethod --- всего уникальных значений 4
  список уникальных значений: ['Bank transfer (automatic)' 'Credit card (automatic)' 'Electronic check'
 'Mailed check']

- MonthlyCharges --- всего уникальных значений 1585
  список уникальных значений: [ 18.25  18.4   18.55 ... 118.6  118.65 118.75]

- TotalCharges --- всего уникальных значений 6531
  список уникальных значений: [' ' '100.2' '100.25' ... '999.45' '999.8' '999.9']


	Для стобцов таблицы "PERSONAL" (размеры (7043, 5)):

- customerID --- всего уникальных значений 7043
  список уникальных значений: ['0002-ORFBO' '0003-MKNFE' '0004-TLHLJ' ... '9992-UJOEL' '9993-LHIEB'
 '9995-HOTOH']

- gender --- всего уникальных значений 2
  список уникальных значений: ['Female' 'Male']

- SeniorCitizen --- всего уникальных значений 2
  список уникальных значений: [0 1]

- Partner --- всего уникальных значений 2
  список уникальных значений: [False  True]

- Dependents --- всего уникальных значений 2
  список уникальных значений: [False  True]


	Для стобцов таблицы "INTERNET" (размеры (5517, 8)):

- customerID --- всего уникальных значений 5517
  список уникальных значений: ['0002-ORFBO' '0003-MKNFE' '0004-TLHLJ' ... '9992-UJOEL' '9993-LHIEB'
 '9995-HOTOH']

- InternetService --- всего уникальных значений 2
  список уникальных значений: ['DSL' 'Fiber optic']

- OnlineSecurity --- всего уникальных значений 2
  список уникальных значений: [False  True]

- OnlineBackup --- всего уникальных значений 2
  список уникальных значений: [False  True]

- DeviceProtection --- всего уникальных значений 2
  список уникальных значений: [False  True]

- TechSupport --- всего уникальных значений 2
  список уникальных значений: [False  True]

- StreamingTV --- всего уникальных значений 2
  список уникальных значений: [False  True]

- StreamingMovies --- всего уникальных значений 2
  список уникальных значений: [False  True]


	Для стобцов таблицы "PHONE" (размеры (6361, 2)):

- customerID --- всего уникальных значений 6361
  список уникальных значений: ['0002-ORFBO' '0003-MKNFE' '0004-TLHLJ' ... '9992-RRAMN' '9992-UJOEL'
 '9993-LHIEB']

- MultipleLines --- всего уникальных значений 2
  список уникальных значений: ['No' 'Yes']

  • В столбцах содержатся числовые и категориальные значения величин. Категориальные признаки в основном двоичные, при загрузке таблиц они преобразовывались в булев тип данных.

  • Судя по номерам customerID телефонией и интернетом пользуется НЕодинаковое число клиентов, есть пользователи только услуг телефонии, не пользующиеся услугами провайдера.

  • Данные кажутся несколько избыточными, необходимый объем определится в дальнейшем исследовании. На данном этапе видно, что данные выгружены в сыром виде, т.е. без разметки целевого признака. Проведём её самостоятельно на основании данных столбца EndDate таблицы contract.

В результате:

  • столбец EndDate должен быть исключен из числа признаков для предотвращения утечки данных,

  • столбец BeginDate можно преобразовать в значение временного промежутка между датой выгрузки и датой заключения договора обслуживания, либо вовсе отбросить это значение,

  • Все таблицы набора данных предполагается объединить в один датасет для обогащения пространства признаков.

Распределение значений, балансы классов.¶

Изначально числовые значения содержатся в 2 столбцах таблицы contract, добавим к ним столбец с информацией об *удельном дневном платеже клиентов* и рассмотрим их описательные статистики, корреляцию и распределения. Затем оценим балансы классов по остальным столбцам набора данных.

In [4]:
# добавим столбец 'day' с количеством дней с даты заключения договора до дат выгрузки (+1 чтобы не получить 0 в знаменателе)
df_dict['contract']['days'] = ((pd.to_datetime('2020-02-01') - df_dict['contract']['BeginDate']).astype('int') + 1)/(24*60*60*10**9)

# добавим столбец 'day_rate' с удельным дневным платежом
df_dict['contract']['day_rate'] = df_dict['contract']['TotalCharges']/df_dict['contract']['days']

# разметим данные для формирования целевого признака
df_dict['contract']['is_active'] = df_dict['contract']['EndDate'].isna()

print('Выведем полученную таблицу:')
df_dict['contract'].head(5)
Выведем полученную таблицу:
Out[4]:
customerID BeginDate EndDate Type PaperlessBilling PaymentMethod MonthlyCharges TotalCharges days day_rate is_active
0 7590-VHVEG 2020-01-01 NaT Month-to-month True Electronic check 29.85 29.85 31.0 0.962903 True
1 5575-GNVDE 2017-04-01 NaT One year False Mailed check 56.95 1889.50 1036.0 1.823842 True
2 3668-QPYBK 2019-10-01 2019-12-01 Month-to-month True Mailed check 53.85 108.15 123.0 0.879268 False
3 7795-CFOCW 2016-05-01 NaT One year False Bank transfer (automatic) 42.30 1840.75 1371.0 1.342633 True
4 9237-HQITU 2019-09-01 2019-11-01 Month-to-month True Electronic check 70.70 151.65 153.0 0.991176 False
In [5]:
# выведем корелляцию и стат.описания для числовых столбов таблицы 'contract'
print('Матрица корреляций:')
print(df_dict['contract'][['MonthlyCharges','TotalCharges','day_rate']].corr())
print()

print('Статистические параметры:')
print(df_dict['contract'][['MonthlyCharges','TotalCharges','day_rate']].describe())
print()

# выведем матрицу диаграмм рассеяния для столбов таблицы 'contract'
print('Матрица диаграмм рассеяния таблицы "contract"')
sns.pairplot(df_dict['contract'][['MonthlyCharges','TotalCharges','day_rate']], diag_kind='hist').fig.set_size_inches(15,15)
plt.show()
Матрица корреляций:
                MonthlyCharges  TotalCharges  day_rate
MonthlyCharges        1.000000      0.651174  0.915041
TotalCharges          0.651174      1.000000  0.763286
day_rate              0.915041      0.763286  1.000000

Статистические параметры:
       MonthlyCharges  TotalCharges     day_rate
count     7043.000000   7043.000000  7043.000000
mean        64.761692   2279.734304     1.952765
std         30.090047   2266.794470     1.016042
min         18.250000      0.000000     0.000000
25%         35.500000    398.550000     0.837685
50%         70.350000   1394.550000     2.023834
75%         89.850000   3786.600000     2.803103
max        118.750000   8684.800000     3.966451

Матрица диаграмм рассеяния таблицы "contract"

Изначально данное небольшое количество числовых признаков (2 столбца) предположительно даёт небольшое количество информации для обучения модели, поэтому было решено добавить синтетичекий числовой признак вида " *удельный дневной платёж* ".

Можно видеть, что распределения далеки от нормальных и скошены влево, т.к. количество малых платежей превалирует над остальными, но есть и другие пики около среднего и в одном станд. отклонении от него вправо.

Корреляция между месячными и суммарными платежами слабая, скорее всего эти величины связаны не столько между собой и линейно сколько с иными показателями и нелинейно.

Удельный дневной платёж day_rate хоть и имеет заметную корреляцию с величиной ежемесячного платежа MonthlyCharges, но зато позволяет отбросить столбец BeginDate с датой начала действия договора обслуживания, поскольку учитывает инфомарцию об этом (вдобавок было непонятно, как использовать столбец с датой для обучения моделей).

Для обучения моделей количественные признаки представляют интерес. Далее оценим балансы классов столбцов с категориальными значениями.

In [6]:
# рассмотрим балансы классов категориальных признаков, выведем их также на графиках
print(f'\nБаланс классов категориальных признаков (для всех таблиц) c круговыми диаграммами')
for file in df_dict:
    print()
    print(f'\t\033[1mТаблица "{file.upper()}"\033[0;0m:\n')
    for col in df_dict[file].columns[1:]:
        if (df_dict[file][col].dtype != 'float64') \
        & (df_dict[file][col].dtype != 'datetime64[ns]'):
            print(f'\t- столбец {col}')
            _t = pd.DataFrame(df_dict[file][col]
                              .value_counts(normalize=True, ascending=False))
            display(_t.style.format('{:.1%}'))
            
            plt.figure(figsize=(5,5))
            plt.pie(_t[col],labels=_t.index,autopct='%1.1f%%')
            plt.show()
Баланс классов категориальных признаков (для всех таблиц) c круговыми диаграммами

	Таблица "CONTRACT":

	- столбец Type
Type
Month-to-month 55.0%
Two year 24.1%
One year 20.9%
	- столбец PaperlessBilling
PaperlessBilling
True 59.2%
False 40.8%
	- столбец PaymentMethod
PaymentMethod
Electronic check 33.6%
Mailed check 22.9%
Bank transfer (automatic) 21.9%
Credit card (automatic) 21.6%
	- столбец is_active
is_active
True 73.5%
False 26.5%
	Таблица "PERSONAL":

	- столбец gender
gender
Male 50.5%
Female 49.5%
	- столбец SeniorCitizen
SeniorCitizen
False 83.8%
True 16.2%
	- столбец Partner
Partner
False 51.7%
True 48.3%
	- столбец Dependents
Dependents
False 70.0%
True 30.0%
	Таблица "INTERNET":

	- столбец InternetService
InternetService
Fiber optic 56.1%
DSL 43.9%
	- столбец OnlineSecurity
OnlineSecurity
False 63.4%
True 36.6%
	- столбец OnlineBackup
OnlineBackup
False 56.0%
True 44.0%
	- столбец DeviceProtection
DeviceProtection
False 56.1%
True 43.9%
	- столбец TechSupport
TechSupport
False 63.0%
True 37.0%
	- столбец StreamingTV
StreamingTV
False 50.9%
True 49.1%
	- столбец StreamingMovies
StreamingMovies
False 50.5%
True 49.5%
	Таблица "PHONE":

	- столбец MultipleLines
MultipleLines
False 53.3%
True 46.7%

Некоторые классы признаков плохо сбалансированы, будем учитывать это в стратификации выборок и при выборе весов категориальных признаков в гиперпарметрах моделей.

Целевой признак имеет баланс классов примерно 3:1, т.е. число вполне значимое, стоит учесть это при разделении выборки и при выборе метрики, поскольку ROC-AUC-мера как известно может недостаточно "штрафовать" модели за False-Positive при дисбалансе класса.

Данные обработаны, разведочный анализ произведён, перед дальнейшей работой над ML-моделями данные из таблиц необходимо объединить в одну для получения общей матрицы признаков.

Объединение таблиц.¶

Объединим таблицы с подготовленными данными в единый датасет для наращивания пространства признаков для обучения моделей.

In [7]:
# используем merge для объединения таблиц, возникшие пропуски в ячейках заменим на False
total = df_dict['contract'].drop(['BeginDate','EndDate','days'], axis=1).merge(df_dict['personal'], how='left', on='customerID')
total = total.merge(df_dict['internet'], how='left', on='customerID')
total = total.merge(df_dict['phone'], how='left', on='customerID')
total.fillna(False, inplace=True)
total['InternetService'].replace(to_replace=False, value='No_Int.Service', inplace=True)

print(f'\033[1mСводная таблица "TOTAL", размеры:\033[0;0m', total.shape)
print(f'\n\t- пропущенных значений:')
print(total.isna().sum())
print('\n\t- фрагмент:')
total.sample(5)
Сводная таблица "TOTAL", размеры: (7043, 20)

	- пропущенных значений:
customerID          0
Type                0
PaperlessBilling    0
PaymentMethod       0
MonthlyCharges      0
TotalCharges        0
day_rate            0
is_active           0
gender              0
SeniorCitizen       0
Partner             0
Dependents          0
InternetService     0
OnlineSecurity      0
OnlineBackup        0
DeviceProtection    0
TechSupport         0
StreamingTV         0
StreamingMovies     0
MultipleLines       0
dtype: int64

	- фрагмент:
Out[7]:
customerID Type PaperlessBilling PaymentMethod MonthlyCharges TotalCharges day_rate is_active gender SeniorCitizen Partner Dependents InternetService OnlineSecurity OnlineBackup DeviceProtection TechSupport StreamingTV StreamingMovies MultipleLines
1182 3164-YAXFY Month-to-month True Electronic check 53.75 3196.00 1.839954 True Male False False False DSL True False True False True True False
7033 9767-FFLEM Month-to-month True Credit card (automatic) 69.50 2625.25 2.269015 True Male False False False Fiber optic False False False False False False False
3068 1810-BOHSY One year True Credit card (automatic) 96.40 4911.05 3.162299 True Male False True False Fiber optic True True False False False True True
4381 7481-ATQQS Month-to-month True Credit card (automatic) 90.85 4515.85 2.907824 False Female True False False Fiber optic False False True False False True True
6221 0042-JVWOJ One year True Bank transfer (automatic) 19.60 471.85 0.595770 True Male False False False No_Int.Service False False False False False False False

Объединённая таблица признаков создана, из-за замены пропущенных значений на False в столбцах с категориальными переменными баланс классов сместился в них в соотв. сторону, считаю, что подобные действия не явялются утечкой в данной задаче, а способствуют обогащению пространства признаков. Дисбаланс классов будем учитывать при разделении датасета на выборки.


Исследование моделей.¶

Для обучения будем пользоваться:

  • *логистической регрессией*,
  • одним из алгоритмов *градиентного бустинга* (предположительно CatBoost),
  • а также *полносвязной нейронной сетью для классификации* (при возможности).
  • В качесиве базовой оценки примем результаты работы DummyClassifier из библиотеки sklearn.

В задаче классификации оперировать только метрикой accuracy будет недостаточно. Будем сравнивать модели по по ROC-AUC мере, лучший порог настроим по F1-мере, в качестве бизнес-метрики применим accuracy.

Формирование выборок.¶

Разделим размеченный датасет total на обучающую и тестовую выборки (в отн. 3:1). Попрбуем сохранить стратификацию по самым несбалансированным классам, чтобы модели не дискриминировали их. Валидационную выборку делать не станем, поскольку оценки эффективности моделей будут получиться с помощью кросс-валидации.

In [9]:
state = np.random.RandomState(606)

# разделим датафрейм на целевой признак и признаки:
features = total.drop(columns=['customerID', 'is_active'],axis=1)
target = total['is_active']

# разделим выборки на тренировочную и тестовую в отношении 3:1:
features_train, features_test, target_train, target_test = train_test_split(features, target, test_size=0.25,
                                                                            random_state=state,
                                                                            stratify=total[['SeniorCitizen','Type', 
                                                                                            'OnlineSecurity', 'is_active']])

print('Для проверки cопоставим размеры выборок.')
print('признаки: обучающая -',features_train.shape, 'тестовая -',features_test.shape)
print('целевой признак: обучающая -',target_train.shape, 'тестовая -',target_test.shape)

print(f'''\nДля проверки работы гиперпараметра "stratify" сопоставим стратификацию классов в полученных выборках.

- обучающая выборка
{features_train[['SeniorCitizen','Type','OnlineSecurity']].value_counts(normalize=True).round(4)}

- тестовая выборка
{features_test[['SeniorCitizen','Type','OnlineSecurity']].value_counts(normalize=True).round(4)}

Баланс классов в целевом признаке:

- обучающая выборка
{target_train.value_counts(normalize=True).round(4)}

- тестовая выборка
{target_test.value_counts(normalize=True).round(4)}''')
Для проверки cопоставим размеры выборок.
признаки: обучающая - (5282, 18) тестовая - (1761, 18)
целевой признак: обучающая - (5282,) тестовая - (1761,)

Для проверки работы гиперпараметра "stratify" сопоставим стратификацию классов в полученных выборках.

- обучающая выборка
SeniorCitizen  Type            OnlineSecurity
False          Month-to-month  False             0.3523
               Two year        False             0.1255
               One year        False             0.1132
True           Month-to-month  False             0.0958
False          Two year        True              0.0943
               Month-to-month  True              0.0835
               One year        True              0.0689
True           Month-to-month  True              0.0189
               One year        False             0.0174
               Two year        True              0.0115
               One year        True              0.0097
               Two year        False             0.0089
dtype: float64

- тестовая выборка
SeniorCitizen  Type            OnlineSecurity
False          Month-to-month  False             0.3521
               Two year        False             0.1261
               One year        False             0.1136
True           Month-to-month  False             0.0954
False          Two year        True              0.0948
               Month-to-month  True              0.0829
               One year        True              0.0687
True           Month-to-month  True              0.0187
               One year        False             0.0176
               Two year        True              0.0119
               One year        True              0.0091
               Two year        False             0.0091
dtype: float64

Баланс классов в целевом признаке:

- обучающая выборка
True     0.7346
False    0.2654
Name: is_active, dtype: float64

- тестовая выборка
True     0.7348
False    0.2652
Name: is_active, dtype: float64

Обработка признаков.¶

Напишем конвейер для обработки признаков: он будет кодировать категориальные переменные и масштабировать количественные - эти операции благодаря конвейеру могут вызываться для каждой передаваемой выборки, в том числе и при кросс-валидации.

In [10]:
# разделим признаки по категориям
cat_features = features.select_dtypes(include='object').columns.values     # категориальные
num_features = features.select_dtypes(include='float64').columns.values    # числовые
bool_features = features.select_dtypes(include='bool').columns.values      # булевы (на всякий случай)

# преобразуем признаки перед передачей в Pipeline c модель. с пом. ColumnTransformer
prepr = ColumnTransformer([
    (
        'cat_no_bool',
         OneHotEncoder(drop=None, handle_unknown='ignore'),
         cat_features
    ),
    (
        'num_no_bool',
        StandardScaler(),
        num_features
    ),
    (
        'bool',
        OneHotEncoder(drop='if_binary', handle_unknown='error'),
        bool_features
    )
])

Полученный конвейер обработки признаков ColumnsTransformer() будем передавать в конвейер Pipeline(), содержащий рассматриваемую ML-модель, а его в третью очередь - на кросс-валидацию или поиск гипер-параметров (далее - ГП) GridSearch().

Константная модель.¶

Получим оценку константной/случайной модели по результатам работы DummyClassifier, примем её за базовую.

In [11]:
# создадим словарь для хранения значений ROC-AUC мер
roc_auc_scores = {}

dummy_random = DummyClassifier(random_state=state, strategy='uniform')
dummy_const = DummyClassifier(random_state=state, strategy='constant', constant=1)

model = [dummy_random, dummy_const]
name = ['CЛУЧАЙНАЯ','КОНСТАНТНАЯ']

# цикл по 2 моделям
for i in range(len(model)):
    clf = Pipeline([           # Pipeline с предобработкой и моделью
        ('prepr', prepr),
        ('estimator', model[i])
    ])
    
    # получение оценок моделей
    print(f'модель - {str(model[i])[:16]}...{str(model[i])[-20:-1]})')
    print(f'ROC-AUC мера {name[i]} модели:',
          cross_val_score(clf, features_train, target_train, 
                          scoring='roc_auc', cv=10).mean(), '\n') # ROC-AUC после кросс-валидации
    
    # добавим результаты в словарь
    roc_auc_scores[f'{name[i]} модель'] = cross_val_score(clf, features_train, target_train, 
                                                                       scoring='roc_auc', cv=10).mean().round(4)
модель - DummyClassifier(... strategy='uniform')
ROC-AUC мера CЛУЧАЙНАЯ модели: 0.5 

модель - DummyClassifier(...strategy='constant')
ROC-AUC мера КОНСТАНТНАЯ модели: 0.5 

CPU times: user 1.15 s, sys: 9.71 ms, total: 1.16 s
Wall time: 1.17 s

Логистическая регрессия.¶

Получив base-line решение, приступим к обучению моделей. Начнём с *логистической регрессии*.

Лучшие гиперараметры найдём поиском по сетке с кросс-валидацией (с пом. GridSearchCV): для устранения переобучения предполагается в основном варьировать параметром C: Inverse of regularization strength. На выходе подберём значение лучшего порога (поскольку непосредственно в модели логистической регрессии из sklearn такого функционала нет, он реализуется через predict_proba()).

In [12]:
model = LogisticRegression(random_state=state)

# Pipeline с предобработкой и моделью
clf = Pipeline([
    ('prepr', prepr),
    ('estimator', model)
])

# поиск лучших гиперпарметров по сетке с кросс-валидацией
grid = {
    'estimator__penalty': ['l1'], #'l2'],
    'estimator__C': [65],
    'estimator__max_iter': range(99,100,1),
    'estimator__class_weight': ['balanced'], #'None'],
    'estimator__solver': ['saga','liblinear']
}

LR_clf = GridSearchCV(
    clf,
    param_grid=grid,
    scoring='roc_auc',
    cv=5, verbose=1, n_jobs=-1
)

LR_clf.fit(features_train, target_train)
Fitting 5 folds for each of 2 candidates, totalling 10 fits
CPU times: user 3.24 s, sys: 1.05 s, total: 4.3 s
Wall time: 4.31 s
Out[12]:
GridSearchCV(cv=5,
             estimator=Pipeline(steps=[('prepr',
                                        ColumnTransformer(transformers=[('cat_no_bool',
                                                                         OneHotEncoder(handle_unknown='ignore'),
                                                                         array(['Type', 'PaymentMethod', 'gender', 'InternetService'], dtype=object)),
                                                                        ('num_no_bool',
                                                                         StandardScaler(),
                                                                         array(['MonthlyCharges', 'TotalCharges', 'day_rate'], dtype=object)),
                                                                        ('bool',
                                                                         OneHotEncoder(drop='if_binary'),...
       'TechSupport', 'StreamingTV', 'StreamingMovies', 'MultipleLines'],
      dtype=object))])),
                                       ('estimator',
                                        LogisticRegression(random_state=RandomState(MT19937) at 0x7FE274C7C340))]),
             n_jobs=-1,
             param_grid={'estimator__C': [65],
                         'estimator__class_weight': ['balanced'],
                         'estimator__max_iter': range(99, 100),
                         'estimator__penalty': ['l1'],
                         'estimator__solver': ['saga', 'liblinear']},
             scoring='roc_auc', verbose=1)
In [13]:
# выведем результаты на экран:
print('\nЛогистическая регрессия\n')
print('- Лучший ROC-AUC = ', LR_clf.best_score_.round(5))
print('- Лучшие параметры:\n\t', {str(i)[11:]:LR_clf.best_params_.get(i) for i in LR_clf.best_params_})
print('- Лучшая модель:\n\t доступна из "LR_clf.best_estimator_"\n')

# добавим результаты в словарь
roc_auc_scores[f'Логистическоая регрессия'] = LR_clf.best_score_
Логистическая регрессия

- Лучший ROC-AUC =  0.97123
- Лучшие параметры:
	 {'C': 65, 'class_weight': 'balanced', 'max_iter': 99, 'penalty': 'l1', 'solver': 'liblinear'}
- Лучшая модель:
	 доступна из "LR_clf.best_estimator_"

Графики ROC и Precision-Recall.¶

Построим графики ROC и Precision-Recall для наглядности.

In [15]:
print('\nROC- и precision-recall-кривые логистической регресси (с лучшими гиперпараметрами)')

# график roc_curve
def roc(model, description):

    plt.figure(figsize=(16,10))
    
    # график для лучшей модели на train
    probabilities_one = model.best_estimator_.predict_proba(features_train)[:,1]
    fpr, tpr, thresholds = roc_curve(target_train, probabilities_one)
    roc_auc = auc(fpr, tpr) 
    
    plt.plot(fpr,tpr, linestyle='--', linewidth=1)

    # график для лучшей модели на train с кросс-валидацией
    probabilities_one_cv = cross_val_predict(model.best_estimator_,
                                             features_train, target_train,
    
    .....
    
    plt.title(f'{description}. Precision-Recall-кривая.', fontsize=12)
    plt.show()

roc(LR_clf, 'Логистическая регрессия')
prec_rec(LR_clf, 'Логистическая регрессия')
ROC- и precision-recall-кривые логистической регресси (с лучшими гиперпараметрами)
[Parallel(n_jobs=-1)]: Using backend SequentialBackend with 1 concurrent workers.
[Parallel(n_jobs=-1)]: Done   1 out of   1 | elapsed:    0.4s remaining:    0.0s
[Parallel(n_jobs=-1)]: Done   5 out of   5 | elapsed:    2.5s finished
[Parallel(n_jobs=-1)]: Using backend SequentialBackend with 1 concurrent workers.
[Parallel(n_jobs=-1)]: Done   1 out of   1 | elapsed:    0.4s remaining:    0.0s
[Parallel(n_jobs=-1)]: Done   5 out of   5 | elapsed:    2.4s finished

Применяя полученную модель для построения графика на всей обучающей выборке (без кросс-валидации) результат получается недостаточно достоверный, поскольку модель, найденная в GridSearch, получает оценки на всей выборке разом вместо усреднённой по блокам и такой результат сильно отличается от ROC-AUC лучшей модели. Особенно это заметно на следующей модели. Однако, если получить необходимые предсказания в кросс-валидации, результат получится более похожий на реальный и сравнимый с best_score_ лучшей модели.

Выбор порога threshold полученной модели.¶

В заданных границах оценка F1 меняется в основном в зависимости от величниы параметра C. Улучшение оказалось возможным, и в результате перебора ГП значение ROC-AUC получилось достаточно высоким. Известно, что точность предсказаний логистической регрессии может сильно меняться в зависимости от порога чувствительности, но в атрибутах LogisticRegression() сдвиг порога threshold не фигурирует и подобрать его как один из гиперпараметров не получится. Оценим оптимальное значение threshold для полученной модели следующим образом.

In [16]:
# найдём значения f1-меры для разных значений порога threshold (на кросс-валидации):
def thrsh(model, description):
    print(f'\n{description}. Уточнение значения порога модели.\n')

    best_F1_cv = 0
    best_threshold_cv = 0
    f1_list_cv = []
    thrsh_list_cv = []
    
    # подготовим список вероятностей положительного класса для модели
    probabilities_one_cv = cross_val_predict(model.best_estimator_,
                                             features_train, target_train,
                                             
    .....
                                             
               loc='lower right')
    plt.show()
    
thrsh(LR_clf, 'Логистическая регрессия')
Логистическая регрессия. Уточнение значения порога модели.

Определёны: best_F1_score = 0.9572 | best_threshold = 0.244
F1-score по умолчанию (при threshold - 0.5) = 0.9473
----------------------------------------------------

Вывод:

  • Подбором гиперпарметров логистической регрессии (например с помощью GridSearchCV) можно настроить модель, не затрагивая значение threshold. Изменяя затем threshold логистической кривой, качество модели можно повысить дополнительно.
  • Улучшение оказалось возможным, найден уточнённый порог threshold=0.244. Поскольку поиск уточнённого значения порога чувствительности велся в кросс-валидации, то полученное значение носит характер усреднённого и теоретически может быть успешно использовано для оценки качества модели на тестовой выборке (но на практике не применялось из-за случайного характера тестовых данных, в результате чего предсказания модели с измененным порогом получат случайную ошибку, для справедливости сравнения нужно либо переходить на повсеместное применение изменённых порогов, либо везде использовать стандартное значение).

Случайный лес.¶

Построим модель *случайного леса* для сравнения. Лучшие ГП также будем искать по сетке с кросс-валидацией, для лучшей модели построим графики и сравним результаты.

In [17]:
%%time

model = RandomForestClassifier(random_state=state, min_samples_leaf=1, max_features='sqrt')

# Pipeline с предобработкой и моделью
clf = Pipeline([
    ('prepr', prepr),
    ('estimator', model)
])

# поиск лучших гиперпарметров по сетке с кросс-валидацией
grid = {
    'estimator__n_estimators': range(1087, 1088, 1),
    'estimator__criterion': ['gini'], #'entropy','log_loss'],
    'estimator__max_depth': range(23, 24, 1),
    'estimator__class_weight': ['balanced'], #'Naone'],
    'estimator__ccp_alpha': [0] #, 0.0001, 10, 100, 1000]
}

RF_clf = GridSearchCV(
    clf,
    param_grid=grid,
    scoring='roc_auc',
    cv=5, verbose=1, n_jobs=-1
)

RF_clf.fit(features_train, target_train)

# выведем результаты на экран:
print('\nСлучайный лес\n')
print(f'- Лучший ROC-AUC = {RF_clf.best_score_.round(5)} ({LR_clf.best_score_.round(5)} у лог.регрессии)')
print('- Лучшие параметры:\n\t', {str(i)[11:]:RF_clf.best_params_.get(i) for i in RF_clf.best_params_})
print('- Лучшая модель:\n\t доступна из "RF_clf.best_estimator_"\n')

# добавим результаты в словарь
roc_auc_scores[f'Случайный лес'] = RF_clf.best_score_
Fitting 5 folds for each of 1 candidates, totalling 5 fits

Случайный лес

- Лучший ROC-AUC = 0.93526 (0.97123 у лог.регрессии)
- Лучшие параметры:
	 {'ccp_alpha': 0, 'class_weight': 'balanced', 'criterion': 'gini', 'max_depth': 23, 'n_estimators': 1087}
- Лучшая модель:
	 доступна из "RF_clf.best_estimator_"

CPU times: user 28.8 s, sys: 218 ms, total: 29 s
Wall time: 29 s
In [18]:
# проверка модели вручную (чтобы понять какие результаты считает GridSearchCV) 
model_test = RandomForestClassifier(random_state=state, min_samples_leaf=1, max_features='sqrt',
                                    n_estimators=1087, criterion='gini', max_depth=23,
                                    class_weight='balanced')

clf_test = Pipeline([
    ('prepr', prepr),
    ('estimator', model_test)
])

score_test = cross_val_score(clf_test, features_train, target_train, scoring='roc_auc', cv=5).mean()

if score_test == RF_clf.best_score_:
    print(f'ROC-AUC-меры после кросс-валидации у проверочной модели и лучшей модели случайного леса совпадают,\n'
          'проверка завершена успешно.')
ROC-AUC-меры после кросс-валидации у проверочной модели и лучшей модели случайного леса совпадают,
проверка завершена успешно.

Графики ROC и Precision-Recall.¶

Для построенной модели классификации изобразим графики ROC и Precision-Recall.

In [19]:
print('\nROC- и precision-recall-кривые случайного леса (с лучшими гиперпараметрами)')

roc(RF_clf, 'Случайный лес')
prec_rec(RF_clf, 'Случайный лес')
ROC- и precision-recall-кривые случайного леса (с лучшими гиперпараметрами)
[Parallel(n_jobs=-1)]: Using backend SequentialBackend with 1 concurrent workers.
[Parallel(n_jobs=-1)]: Done   1 out of   1 | elapsed:    4.8s remaining:    0.0s
[Parallel(n_jobs=-1)]: Done   5 out of   5 | elapsed:   25.2s finished
[Parallel(n_jobs=-1)]: Using backend SequentialBackend with 1 concurrent workers.
[Parallel(n_jobs=-1)]: Done   1 out of   1 | elapsed:    4.8s remaining:    0.0s
[Parallel(n_jobs=-1)]: Done   5 out of   5 | elapsed:   24.1s finished

Как говорилось в разделе выше (о логистич. регрессии): кривые, полученные по результатам предсказаний модели на тренировочной выборке, могут сильно отличаться в зависимости как от применённой модели, так и того, каким образом результаты были получены - в кросс-валидации или без неё (на всей выборке целиком).

Это поведение особенно хорошо заметно на "деревянных" моделях, т.к. лес способен очень хорошо запомнить выборку целиком, но при подаче на вход тестовой выборки, результаты будут стремиться к показаниям случайной модели. Это же отображено на графиках: случайный лес, делающий предсказание на тренировочной выборке без кросс-валидации, показывает кривые с площадью под ними равной 1, при сборе предсказаний в кросс-валидации на той же тренировочной выборке показатели устремляются в сторону уменьшения и приобретают более реалистичный характер.

Выбор порога threshold полученной модели.¶

Далее уточним значение порога классификации модели случайного леса.

In [20]:
thrsh(RF_clf, 'Случайный лес')
Случайный лес. Уточнение значения порога модели.

Определёны: best_F1_score = 0.9382 | best_threshold = 0.546
F1-score по умолчанию (при threshold - 0.5) = 0.9366
----------------------------------------------------

Вывод:

  • построенная деревянная модель выглядит достаточно эффективной, для этого число оценивающих деревьев n_estimators было выбрано большим (для усреднения оценки), а число max_depth - близким к количеству признаков (интуитивное предположение), порог также был уточнён, но учитывая довольно случайный характер тестовых данных изменение порога может повести себя непредсказуемо и для однообразия результатов его лучше не менять,
  • скорее всего модель переобучена в силу природы деревянных моделей и небольшой возможности регулирования. Применением бустинговых моделей может принести более устойчивый и более высокий результат.

Градиентный бустинг.¶

Модели *градиентного бустинга* обещают быть наиболее эффективными для задач подобного типа, но поскольку все они подразумевают использование самостоятельных библиотек, то в приложении к нашему примеру использовались не все доступные методы. Предпочтение отдано библиотеке CatBoost как наиболее знакомой.

В моделях CatBoost есть встроенные инструменты для преобразования категориальных признаков (а также заполнения пропусков в числовых значениях) нужно только указать список столбцов с категориальными величинами. Поскольку конвейер преобразования признаков уже создавался, то можно было подать в модель преобразованные им признаки, однако сравнение с встроенным в CatBoost конвейером показало пусть и небольшой, но перевес в пользу последнего. По всей видимости он не только преобразует признаки, но и создаёт новые по своим алгоритмам. Модели передавался список столбцов cat_bool_features, содержащих категориальные признаки и признаки ранее размеченные как булевые.

In [21]:
# подготовим список столбцов с категориальными признаками
cat_bool_features = features.select_dtypes(include=['object','bool']).columns.values

# делим данные на тренировочную и валидационную части для CatBoost
X_train, X_validation, y_train, y_validation = train_test_split(features_train, target_train,
                                                                test_size=0.25,
                                                                random_state=state)
print(X_train.shape, X_validation.shape)

#cоздаем классификатор
model = CatBoostClassifier(
    eval_metric='AUC',
    iterations=300,
    l2_leaf_reg = 3, 
    learning_rate = 0.1,
    early_stopping_rounds=20,
    use_best_model=True,
    random_seed=606,
    custom_loss=['AUC','F1'],
    verbose=0,
    #train_dir='cb_test',
    #plot=True
)

# cбучаем модель с валидационной выборкой
model.fit(
    X_train, y_train,
    cat_features=cat_bool_features,
    eval_set=(X_validation, y_validation)
)

print(f'\nУстновленные гиперпараметры модели:')
for i in model.get_params().keys():
      print(f'\t- {i}: {model.get_params().get(i)}')
        
print('\nROC-AUC мера модели (усредненная, БЕЗ кросс-валиадции):',
      np.mean(model.evals_result_.get('validation').get('AUC')).round(5))
(3961, 18) (1321, 18)

Устновленные гиперпараметры модели:
	- iterations: 300
	- learning_rate: 0.1
	- l2_leaf_reg: 3
	- random_seed: 606
	- use_best_model: True
	- verbose: 0
	- custom_loss: ['AUC', 'F1']
	- eval_metric: AUC
	- early_stopping_rounds: 20

ROC-AUC мера модели (усредненная, БЕЗ кросс-валиадции): 0.94425
  • Для бинарной классификации CatBoost предлагает использование 2 функций потерь loss_function: Logloss и CrossEntropy, по умолчанию используется первая - по ним ведётся сравнение и оптимизация моделей. Оценочные метрики, которые проще интерпретировать, могут быть указаны в атрибуте custom_loss = ['AUC', 'F1']. В нашем случае модели ранжировались с использованием метрики, отличной от функции потерь-AUC, она указывалась в атрибуте eval_metric='AUC'.
  • Для сокращения времени обучения используется параметр early_stopping_rounds=20, который позволяет прерывать обучение, если на протяжении указанного числа итераций функция потерь не убывает, и который включён по умолчанию, если в модель передаётся валидационный набор данных.
  • Аналогично use_best_model=True использовался, чтобы избегать переобучения моделей и ограничивать глубину решающих деревьев, когда начиналось переобучение.
  • Шаг обучения learning_rate и количество итераций iterations подбирались таким образом, чтобы лучшая конфигурация приходилась на последние шаги.
  • Немаловажно отметить необходимость выделения валидационной выборки из обучающих данных, т.к. она позволит избежать переобучения и сделает предсказания более устойчивыми.

Кросс-валидация.¶

Более устойчивые оценки модели можно получить кросс-валидацией, для этого в CatBoost используется функция cv() с присущими ей методами, сравнение же велось с GridSearchCV.

Можно также отметить, что в состоянии "по умолчанию" модель CatBoost относительно быстро выдаёт хорошие результаты и решительно улучшить их не так просто.

In [22]:
# загрузим модель без eval_metric и custom_loss
model_ = CatBoostClassifier(
    iterations=300,
    l2_leaf_reg = 3, 
    learning_rate = 0.1,
    early_stopping_rounds=20,
    use_best_model=True,
    random_seed=606,
    verbose=0
)

# гиперпараметры для подбора
cb_params = {
    'iterations': [model_.get_params().get('iterations')],
    'learning_rate': [model_.get_params().get('learning_rate')],
    'loss_function' : ['Logloss'],
    'eval_metric' : ['AUC'],
    'random_seed' : [606]
            }
# обучение модели
grid = GridSearchCV(estimator=model_, param_grid=cb_params, scoring='roc_auc', cv=5)
grid.fit(X_train, y_train,
         cat_features=cat_bool_features,
         eval_set=(X_validation, y_validation),
         verbose=0)

# вывод на экран
print('ROC-AUC мера модели на кросс-валиадции (усредненная):', grid.best_score_.round(5))
#print('Лучшие параметры:', grid.best_params_)
ROC-AUC мера модели на кросс-валиадции (усредненная): 0.96744

Результаты, получаемые на кросс-валидации получаются более устойчивыми и приближенными к реальным показателям качества модели. Итоговая мера ROC-AUC достаточно высока: выше чем у случайного леса, но ниже чем у логистической регрессии. Гибкость настройки модели и скорость обучения можно признать преимуществом перед другими моделями.

Графики ROC и Precision-Recall.¶

Построим графики ROC и Precision-Recall и рассмотрим возможность настройки порога чувствительности threshold модели.

Созданные ранее функции для построения графиков придётся переписать, поскольку не все методы sklearn работают с объектами из CatBoost, поэтому обратимся к соответствующим инструментам библиотеки CatBoost.

In [24]:
# график roc_curve
def roc_cb(model, description):

    plt.figure(figsize=(16,10))
    
    # график ROC через predict_proba() на train выборке
    probabilities_one = model.predict_proba(features_train)[:,1]
    fpr, tpr, thresholds = roc_curve(target_train, probabilities_one)
    roc_auc = auc(fpr, tpr)
    plt.plot(fpr,tpr, linestyle='-', linewidth=1)
    
    .....
    
                   loc="lower left")
    plt.title(f'{description}. Precision-Recall-кривая.', fontsize=12)
    plt.show()

roc_cb(model,'CatBoost')
prec_rec_cb(model, 'CatBoost')

Полученные графики отображают поведение бустинговой модели на всей обучающей выборке без кросс-валидации результатов (для экономии времени на разработку), что делает вычисления значения площади под кривыми не совсем адекватным, но тем не менее даёт наглядное представление о качестве построенной модели. Кросс-валидированные значения ROC-AUC расположены в предыдущем разделе.

Выбор порога threshold полученной модели.¶

Повторим процедуру выбора нового порога классификации. Функцию для нахождения threshold пришлось переписать.

In [25]:
# найдём значения f1-меры для разных значений порога threshold (на кросс-валидации)

def thrsh_cb(model, description, verbose=None):
    print(f'\n{description}. Уточнение значения порога модели.\n')
    
    # списки для отбора значений
    best_F1 = 0

    .....
    
    # инициализация KFold, который будет разбивать выборки на train и test для кросс-валидации
    skf = KFold(n_splits=10, random_state=state, shuffle=True)
    
    # цикл генерации train и test выборок по индексам из KFold
    for train_index, test_index in skf.split(features_train, target_train):
        
    .....
        
              loc='lower left')
    plt.show()
    
thrsh_cb(model, 'CatBoost')
CatBoost. Уточнение значения порога модели.

Определёны: best_F1_score = 0.9535 | best_threshold = 0.5455
F1-score по умолчанию (при threshold - 0.5) = 0.9524
----------------------------------------------------

Вывод:

  • Значение порога выбиралось по демонстрируемым F1-мерам на перебираемых последовательно величинах с шагом 0,0001. Результаты получены на кросс-валидации. На полученном графике можно отметить близость порогов классификации 0.5, выбираемого по умолчанию, и порога полученного сравнением. Менять threshold модели не имеет практического смысла.

  • Модель CatBoost демонстрирует большую устойчивость. Результаты, получаемые на кросс-валидации получаются более приближенными к показаниям на реальных данных. Итоговая мера ROC-AUC достаточно высока.

Нейронная сеть для классификации.¶

Логистическая регрессия и модели деревьев на преобразованном датасете обучаются довольно хорошо, интересно сравнить исходы этих процессов с результатами, которые может выдать нейронная сеть.

Применение нейросети обосновать непросто: как уже было сказано выше, задачу классифифкации на табличных данных достаточно эффективно решают ML-алгоритмы, у нас по сути нет избыточной информации в данных, признаки векторизованы, их взаимное расположение не несёт дополнительной информации: это не тексты, изображения или звук.

Тем не менее применимость нейросетевого подхода в обработке табличных данных видится заслуживающей внимания. Нейронные сети являются наиболее универсальными классификаторами, поскольку весами связей нейронов вполне можно выделять определённые структуры в данных. Проведём исследование классифицирующей способности полносвязных нейронных сетей, используя функции и методы библиотеки Keras/TensorFlow.

Предобработка, параметры, архитектура сети.¶

  • Напрашивается сравнение "алгоритмической" логистической регрессии из sklearn и логистической регрессии, реализованной в однослойной нейронной сети с одним выхдным нейроном с сигмоидной функцией активации для получения значений классов 0 и 1 на выходе.
isolated
  • В качестве функции потерь для бинарной классификации имеет смысл использовать *Binary Cross-Entropy* или *BCE* (англ. «бинарная кросс-энтропия»). Метрику accuracy применять не можем: у неё нет производной, поэтому градиентный спуск работать не будет. *BCE* вычисляется как $BCE = -log(p)$. Если вероятность правильного ответа $p$ приблизительно равна единице, то $-log(p)$ — положительное число, близкое к нулю и ошибка будет маленькой. Когда вероятность правильного ответа $p≈0$, то $-log(p)$ — большое положительное число соответсвенно ошибка тоже большая.
isolated
In [26]:
# нормализуем значения признаков для обучающей и тестовой выборок к диапазону 0-1
scaler = MinMaxScaler(feature_range=(0, 1))

# + ColumnTransformer на исходные features
features_train_neu = scaler.fit_transform(prepr.fit_transform(features_train))
features_test_neu = scaler.fit_transform(prepr.fit_transform(features_test))
print(features_train_neu.shape, features_test_neu.shape)
(5282, 26) (1761, 26)
In [27]:
# обучающую выборку для нейронных сетей разделим на обучающую и валидационную части
X_train, X_validation, y_train, y_validation = train_test_split(features_train_neu,
                                                                target_train,
                                                                test_size=0.25,
                                                                random_state=state)

Однослойная нейросеть - логистическая регрессия.¶

Для сравнения с логистической регрессии из sklearn реализуем её в однослойной нейронной сети с одним выхдным нейроном с сигмоидной функцией активации.

# строим логистическую регрессию в виде однослойной нейросети с сигмоидной функцией активации
model_n = keras.models.Sequential() # инициализация модели из keras

# добавим слой с выходным нейроном
model_n.add(keras.layers.Dense(units=1,
                               input_dim=X_train.shape[1],
                               activation='sigmoid'))

# метрика кач-ва, функция потерь и алгоритм спуска
model_n.compile(loss='binary_crossentropy', optimizer='adam', metrics=['AUC'])

# обучаем нейросеть, указываем валидационную выборку
model_n.fit(X_train, y_train,
            epochs=10000, verbose=2,
            validation_data=(X_validation, y_validation))

# предсказания и оценки на обучающей и валидационной выборках (без кросс-валидации)
pred_trn = model_n.predict(X_train) > 0.5
pred_val = model_n.predict(X_validation) > 0.5

print()
print("ROC-AUC на обучающей выборке:", roc_auc_score(y_train,pred_trn).round(5))
print("ROC-AUC на валидационной выборке:", roc_auc_score(y_validation,pred_val).round(5))

# добавим результаты в словарь
roc_auc_scores[f'Нейросеть (логистич.регрессия)'] = roc_auc_score(y_validation,pred_val).round(5)

Вывод:

Epoch 1/10000
124/124 - 2s - loss: 1.0920 - auc: 0.4452 - val_loss: 0.8686 - val_auc: 0.4569 - 2s/epoch - 13ms/step
Epoch 2/10000
124/124 - 0s - loss: 0.7449 - auc: 0.4584 - val_loss: 0.6526 - val_auc: 0.5048 - 406ms/epoch - 3ms/step
Epoch 3/10000
124/124 - 0s - loss: 0.6214 - auc: 0.5346 - val_loss: 0.5753 - val_auc: 0.5979 - 395ms/epoch - 3ms/step

......

Epoch 9997/10000
124/124 - 0s - loss: 0.1949 - auc: 0.9708 - val_loss: 0.2128 - val_auc: 0.9702 - 396ms/epoch - 3ms/step
Epoch 9998/10000
124/124 - 0s - loss: 0.1949 - auc: 0.9706 - val_loss: 0.2129 - val_auc: 0.9701 - 397ms/epoch - 3ms/step
Epoch 9999/10000
124/124 - 0s - loss: 0.1948 - auc: 0.9707 - val_loss: 0.2127 - val_auc: 0.9703 - 395ms/epoch - 3ms/step
Epoch 10000/10000
124/124 - 0s - loss: 0.1949 - auc: 0.9707 - val_loss: 0.2128 - val_auc: 0.9702 - 393ms/epoch - 3ms/step

ROC-AUC на обучающей выборке: 0.89513
ROC-AUC на валидационной выборке: 0.88013
CPU times: user 51min 3s, sys: 10min 50s, total: 1h 1min 53s
Wall time: 1h 2min 55s
In [29]:
model_n.summary()
Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 dense (Dense)               (None, 1)                 27        
                                                                 
=================================================================
Total params: 27
Trainable params: 27
Non-trainable params: 0
_________________________________________________________________

Значение ROC-AUC на тренировочной и валидационной выборках разные, но довольно близкие, станем считать, что сеть приобрела устраивающую нас обобщающую способность. Точность можно повысить дальнейшим увеличением количества итераций, но для учебных целей остновимся на выбранном значении.

Многослойная нейросеть для бинарной классификации.¶

  • Помимо однослойной сети имеет смысл рассмотреть и многослойную полносвзяную нейронную сеть, т.к. есть предположение, что несмотря на большую сложность она может обучаться быстрее и эффективнее однослойной. Специализированных нейросетевых архитектур для классификации табличных данных не существует (как ResNet, LeNet и др. для изображений). Чаще всего используются сети прямого распространения: на входные нейроны подаются признаки классифицируемого объекта, а на выходе формируется код класса.

  • Такие сети обычно многослойные: со входных нейронов элементы вектора признаков распределяются на все нейроны скрытых слоёв нейросети. В результате объекты разделяются на классы в пространстве признаков большем, чем начальное. Удачно построив архитектуру и подобрав параметры сети, теоретически можно добиться хороших результатов классификации даже там, где прочие методы неэффективны. Но поскольку наилучшая конфигурация сети заранее неизвестна, то приходится подбирать её и настраивать параметры экспериментально.

# строим многослойную полносвязную нейросеть
model_n1 = keras.models.Sequential() # инициализация модели из keras

# испытательная конфигурация сети, добавим несколько полносвязных слоёв к выходному
model_n1.add(keras.layers.Dense(units=X_train.shape[1]*3,
                                input_dim=X_train.shape[1],
                                kernel_regularizer='l2',
                                #bias_regularizer='l2',
                                activity_regularizer='l2',
                                activation='tanh'))

model_n1.add(keras.layers.Dense(units=X_train.shape[1]*2,
                                kernel_regularizer='l2',
                                #bias_regularizer='l2',
                                activity_regularizer='l2',
                                activation='relu'))

model_n1.add(keras.layers.Dense(units=X_train.shape[1]//6,
                                activation='relu'))
# слой с выходным нейроном
model_n1.add(keras.layers.Dense(units=1,
                                activation='sigmoid'))
# метрика кач-ва, функция потерь и алгоритм спуска
model_n1.compile(loss='binary_crossentropy', optimizer='adam', metrics=['AUC'])

# обучаем нейросеть, указываем валидационную выборку
model_n1.fit(X_train, y_train,
             epochs=5000, verbose=2,
             validation_data=(X_validation, y_validation))

# предсказания и оценки на обучающей и валидационной выборках (без кросс-валидации)
pred_trn = model_n1.predict(X_train) > 0.5
pred_val = model_n1.predict(X_validation) > 0.5

print()
print("ROC-AUC на обучающей выборке:", roc_auc_score(y_train,pred_trn).round(5))
print("ROC-AUC на валидационной выборке:", roc_auc_score(y_validation,pred_val).round(5))

# добавим результаты в словарь
roc_auc_scores[f'Нейросеть (полносвязная многослойная)'] = roc_auc_score(y_validation,pred_val).round(5)

Вывод:

Epoch 1/5000
124/124 - 2s - loss: 1.1845 - auc: 0.7770 - val_loss: 0.7700 - val_auc: 0.8446 - 2s/epoch - 14ms/step
Epoch 2/5000
124/124 - 1s - loss: 0.6407 - auc: 0.8417 - val_loss: 0.5370 - val_auc: 0.8517 - 610ms/epoch - 5ms/step
Epoch 3/5000
124/124 - 1s - loss: 0.5030 - auc: 0.8580 - val_loss: 0.4807 - val_auc: 0.8637 - 611ms/epoch - 5ms/step

.....

Epoch 4997/5000
124/124 - 1s - loss: 0.1905 - auc: 0.9762 - val_loss: 0.2064 - val_auc: 0.9679 - 613ms/epoch - 5ms/step
Epoch 4998/5000
124/124 - 1s - loss: 0.1813 - auc: 0.9774 - val_loss: 0.2063 - val_auc: 0.9663 - 614ms/epoch - 5ms/step
Epoch 4999/5000
124/124 - 1s - loss: 0.1959 - auc: 0.9746 - val_loss: 0.2089 - val_auc: 0.9673 - 670ms/epoch - 5ms/step
Epoch 5000/5000
124/124 - 1s - loss: 0.1924 - auc: 0.9766 - val_loss: 0.2459 - val_auc: 0.9658 - 621ms/epoch - 5ms/step

ROC-AUC на обучающей выборке: 0.92609
ROC-AUC на валидационной выборке: 0.92184
CPU times: user 40min 38s, sys: 10min 54s, total: 51min 32s
Wall time: 52min 7s
In [31]:
model_n1.summary()
Model: "sequential_1"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 dense_1 (Dense)             (None, 78)                2106      
                                                                 
 dense_2 (Dense)             (None, 52)                4108      
                                                                 
 dense_3 (Dense)             (None, 4)                 212       
                                                                 
 dense_4 (Dense)             (None, 1)                 5         
                                                                 
=================================================================
Total params: 6,431
Trainable params: 6,431
Non-trainable params: 0
_________________________________________________________________

Сперва значения ROC-AUC на тренировочной и валидационной выборках получались разные и неблизкие, что намекает на недообученность модели на этом этапе. Однако в ходе настройки сети (функции активации, число итерций) ситуация в значительной мере улучшилась. Судя по summary() общее число связей получалось довольно большим, для достижения лучших результатов число параметров было уменьшено, а количество эпох - увеличено. Настраивать порог считаю неэффективным из-за случайных объектов, отражённых в тестовой выборке.

В целом создание нейросетей для задач классификации табличных данных заслуживает внимания, но этот процесс довольно ресурсоёмок и спроектировать сеть с удачной архитектурой довольно непросто, в данной работе эксперимент привёл к параметрам, указанным выше и на этом мы остановимся.

Вывод:

  • Правильный выбор размера сети очень важен (т.е. количество связей между нейронами, которые настраиваются в процессе обучения и обратного распространения ошибки). Сеть с малым количеством весов наврядли выполнит сложное и качественное разделение классов, а чрезмерное увеличение числа связей (до значений количества объектов в обучающией выборке и выше его) вероятнее всего принудит сеть к простому запоминанию и воспроизведению всех комбинаций из обучающих примеров.

  • Очевидно, что увеличение числа обучающих примеров или сокращение числа связей при их избытке позволят улучшить обобщающие способности сети, но оба этих пути чреваты ростом затрат на вычисления или ухудшением точности предсказаний сети. Механизм обратного распространения ошибки в свою очередь также позволяет увеличить точность предсказаний сети, но требует роста числа эпох, что сильно увеличивает время вычислений.

Выбор наилучшего решения. Выводы.¶

Построение и анализ поведения моделей завершены, выведем общие результаты и сравним модели между собой.

In [32]:
# ROC-AUC построенных моделей
print('ROC-AUC построенных моделей:')
pd.DataFrame.from_dict(roc_auc_scores,
                       orient='index',
                       columns=['ROC-AUC']).sort_values(by='ROC-AUC', ascending=True).round(4)
ROC-AUC построенных моделей:
Out[32]:
ROC-AUC
CЛУЧАЙНАЯ модель 0.5000
КОНСТАНТНАЯ модель 0.5000
Нейросеть (логистич.регрессия) 0.8801
Нейросеть (полносвязная многослойная) 0.8957
Случайный лес 0.9353
Градиентный бустинг CatBoost 0.9599
Логистическоая регрессия 0.9712

По выбранной метрике ROC-AUC лучший результат, как это ни удивительно, показала логистическая регрессия, её мы и примем за финальную модель. Оценим качество на отложенной тестовой выборке. Дополнительной метрикой считалась accuracy, вычислим также её. Результаты получим кросс-валидацией.

In [33]:
# получим метрики лучшей модели на тестовой выборке с пом. кросс-валидации
scores = cross_validate(LR_clf.best_estimator_,
                        features_test,
                        target_test,
                        cv=10,
                        scoring=['roc_auc', 'accuracy'])
print('Метрики лучшей модели на тестовой выборке (в кросс-валиадции):')
print('-\tROC-AUC =', scores['test_roc_auc'].mean().round(5))
print('-\tAccuracy =', scores['test_accuracy'].mean().round(5))
Метрики лучшей модели на тестовой выборке (в кросс-валиадции):
-	ROC-AUC = 0.96798
-	Accuracy = 0.91537

Значимость признаков, дисбаланс классов в целевом признаке.¶

Окончательно убедившись в превосходстве выбранной модели логистической регрессии LR_clf, рассмотрим подробнее "внутреннее устройство" результата:

  • как модель определяет значимость признаков (какие из них внесли наибольший вклад в результат),
  • как модель обрабатывает классы целевого признака (по матрице ошибок и отчёту классификации).
In [36]:
# построим график значимости признаков на основе permutation_importance
features_names = features_test.columns #названия столбцов

# получение словаря с permutation_importance
result = permutation_importance(LR_clf.best_estimator_, features_test, target_test, n_repeats=10, random_state=state)

# преобразуем в pd.Series для построения гистограммы
model_importances = pd.Series(result['importances_mean'], index=features_names)

# построение гистограммы значений со станд. отклонением
fig, ax = plt.subplots(figsize=(10,7))
model_importances.plot.bar(yerr=result['importances_std'], ax=ax)
ax.set_title("Feature importances")
ax.set_ylabel("Среднее значение")
fig.tight_layout()
plt.show()

Если верить полученному графику, то наибольшую значимость для модели представляют 2-3 столбца при чём с небольшим станд. отклонением, т.е. по большому счёту модель можно довольно сильно "облегчить", отбросив менее значимые признаки без большого вреда для результата. Или получать предсказания с меньшим количеством входных данных. Сгенерированный признак удельный платёж действительно оказался значимым.

Функция classification_report из sklearn возвращающает recall, precision и F-меру для каждого из классов, а также количество экземпляров каждого класса, и, наряду с матрицей ошибок, даёт достаточно информации для понимания того, как текущая модель работает с классами целевого признака, что довольно значимо в случае несбалансированной выборки.

In [37]:
# classification_report
y_true = target_test
y_pred = LR_clf.best_estimator_.predict(features_test)
target_names = ['class 0', 'class 1']
print('Матрица мер классов целевого признака (на тестовой выборке):\n')
print(classification_report(y_true, y_pred, target_names=target_names))
#print(confusion_matrix(y_true, y_pred))

# матрица ошибок
print('\nМатрица ошибок (на тестовой выборке):')
ConfusionMatrixDisplay(confusion_matrix(y_true, y_pred), display_labels=target_names).plot()
plt.show()
Матрица мер классов целевого признака (на тестовой выборке):

              precision    recall  f1-score   support

     class 0       0.82      0.90      0.86       467
     class 1       0.96      0.93      0.95      1294

    accuracy                           0.92      1761
   macro avg       0.89      0.92      0.90      1761
weighted avg       0.93      0.92      0.92      1761


Матрица ошибок (на тестовой выборке):

Вывод:

  • Полученная модель позволяет предсказывать отток клиентов оператора, показатели точности можно изучить более подробно для понимания области применимости модели. На основании предсказаний можно предпринимать усилия по удержанию клиентов потенциально готовых прекратить использование услуг оператора. Теперь делать это можно не в случайном порядке, а имея обоснование и затрачивая меньше ресурсов.

  • Если грубых ошибок в исследовании не допущено, то можно сделать вывод, что потенциал применения "классических" алгоритмов машинного обучения далеко не исчерпан и не обязателно в любой задаче применять бустинговые модели или нейронные сети, если схожих показателей можно достичь быстрее и соответсвенно с меньшими затратами.


image.png